/*
 * Copyright 2014 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.aplink.generic.google.maps.heatmaps;

import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;

import android.graphics.Bitmap;
import android.graphics.Color;
import android.support.v4.util.LongSparseArray;

import com.aplink.generic.google.maps.geometry.Bounds;
import com.aplink.generic.google.maps.geometry.Point;
import com.aplink.generic.google.maps.quadtree.PointQuadTree;
import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.Tile;
import com.google.android.gms.maps.model.TileProvider;

/**
 * Tile provider that creates heatmap tiles.
 */
public class HeatmapTileProvider implements TileProvider {

    /**
     * Builder class for the HeatmapTileProvider.
     */
    public static class Builder {
        // Required parameters - not final, as there are 2 ways to set it
        private Collection<WeightedLatLng> data;

        private Gradient gradient = HeatmapTileProvider.DEFAULT_GRADIENT;
        private double opacity = HeatmapTileProvider.DEFAULT_OPACITY;
        // Optional, initialised to default values
        private int radius = HeatmapTileProvider.DEFAULT_RADIUS;

        /**
         * Constructor for builder. No required parameters here, but user must
         * call either data() or weightedData().
         */
        public Builder() {
        }

        /**
         * Call when all desired options have been set. Note: you must set data
         * using data or weightedData before this!
         * 
         * @return HeatmapTileProvider created with desired options.
         */
        public HeatmapTileProvider build() {
            // Check if data or weightedData has been called
            if (data == null) {
                throw new IllegalStateException(
                        "No input data: you must use either .data or "
                                + ".weightedData before building");
            }

            return new HeatmapTileProvider(this);
        }

        /**
         * Setter for data in builder. Must call this or weightedData
         * 
         * @param val
         *            Collection of LatLngs to put into quadtree. Should be
         *            non-empty.
         * @return updated builder object
         */
        public Builder data(final Collection<LatLng> val) {
            return weightedData(HeatmapTileProvider.wrapData(val));
        }

        /**
         * Setter for gradient in builder
         * 
         * @param val
         *            Gradient to color heatmap with.
         * @return updated builder object
         */
        public Builder gradient(final Gradient val) {
            gradient = val;
            return this;
        }

        /**
         * Setter for opacity in builder
         * 
         * @param val
         *            Opacity of the entire heatmap in range [0, 1]
         * @return updated builder object
         */
        public Builder opacity(final double val) {
            opacity = val;
            // Check that opacity is in range
            if ((opacity < 0) || (opacity > 1)) {
                throw new IllegalArgumentException(
                        "Opacity must be in range [0, 1]");
            }
            return this;
        }

        /**
         * Setter for radius in builder
         * 
         * @param val
         *            Radius of convolution to use, in terms of pixels. Must be
         *            within minimum and maximum values of 10 to 50 inclusive.
         * @return updated builder object
         */
        public Builder radius(final int val) {
            radius = val;
            // Check that radius is within bounds.
            if ((radius < HeatmapTileProvider.MIN_RADIUS)
                    || (radius > HeatmapTileProvider.MAX_RADIUS)) {
                throw new IllegalArgumentException("Radius not within bounds.");
            }
            return this;
        }

        /**
         * Setter for data in builder. Must call this or data
         * 
         * @param val
         *            Collection of WeightedLatLngs to put into quadtree. Should
         *            be non-empty.
         * @return updated builder object
         */
        public Builder weightedData(final Collection<WeightedLatLng> val) {
            this.data = val;

            // Check that points is non empty
            if (this.data.isEmpty()) {
                throw new IllegalArgumentException("No input points.");
            }
            return this;
        }
    }

    /**
     * Default gradient for heatmap.
     */
    public static final Gradient DEFAULT_GRADIENT = new Gradient(
            HeatmapTileProvider.DEFAULT_GRADIENT_COLORS,
            HeatmapTileProvider.DEFAULT_GRADIENT_START_POINTS);

    /**
     * Colors for default gradient. Array of colors, represented by ints.
     */
    private static final int[] DEFAULT_GRADIENT_COLORS = {
            Color.rgb(102, 225, 0), Color.rgb(255, 0, 0) };

    /**
     * Starting fractions for default gradient. This defines which percentages
     * the above colors represent. These should be a sorted array of floats in
     * the interval [0, 1].
     */
    private static final float[] DEFAULT_GRADIENT_START_POINTS = { 0.2f, 1f };

    /**
     * Default (and maximum possible) maximum zoom level at which to calculate
     * maximum intensities
     */
    private static final int DEFAULT_MAX_ZOOM = 11;

    /**
     * Default (and minimum possible) minimum zoom level at which to calculate
     * maximum intensities
     */
    private static final int DEFAULT_MIN_ZOOM = 5;

    /**
     * Default opacity of heatmap overlay
     */
    public static final double DEFAULT_OPACITY = 0.7;

    /**
     * Default radius for convolution
     */
    public static final int DEFAULT_RADIUS = 20;

    /**
     * Maximum radius value.
     */
    private static final int MAX_RADIUS = 50;

    /**
     * Maximum zoom level possible on a map.
     */
    private static final int MAX_ZOOM_LEVEL = 22;

    /**
     * Minimum radius value.
     */
    private static final int MIN_RADIUS = 10;

    /**
     * Assumed screen size (pixels)
     */
    private static final int SCREEN_SIZE = 1280;

    /**
     * Tile dimension, in pixels.
     */
    private static final int TILE_DIM = 512;

    /**
     * Size of the world (arbitrary). Used to measure distances relative to the
     * total world size. Package access for WeightedLatLng.
     */
    static final double WORLD_WIDTH = 1;

    /**
     * Converts a grid of intensity values to a colored Bitmap, using a given
     * color map
     * 
     * @param grid
     *            the input grid (assumed to be square)
     * @param colorMap
     *            color map (created by generateColorMap)
     * @param max
     *            Maximum intensity value: maps to 100% on gradient
     * @return the colorized grid in Bitmap form, with same dimensions as grid
     */
    static Bitmap colorize(final double[][] grid, final int[] colorMap,
            final double max) {
        // Maximum color value
        final int maxColor = colorMap[colorMap.length - 1];
        // Multiplier to "scale" intensity values with, to map to appropriate
        // color
        final double colorMapScaling = (colorMap.length - 1) / max;
        // Dimension of the input grid (and dimension of output bitmap)
        final int dim = grid.length;

        int i, j, index, col;
        double val;
        // Array of colors
        final int colors[] = new int[dim * dim];
        for (i = 0; i < dim; i++) {
            for (j = 0; j < dim; j++) {
                // [x][y]
                // need to enter each row of x coordinates sequentially (x
                // first)
                // -> [j][i]
                val = grid[j][i];
                index = (i * dim) + j;
                col = (int) (val * colorMapScaling);

                if (val != 0) {
                    // Make it more resilient: cant go outside colorMap
                    if (col < colorMap.length) {
                        colors[index] = colorMap[col];
                    } else {
                        colors[index] = maxColor;
                    }
                } else {
                    colors[index] = Color.TRANSPARENT;
                }
            }
        }

        // Now turn these colors into a bitmap
        final Bitmap tile = Bitmap.createBitmap(dim, dim,
                Bitmap.Config.ARGB_8888);
        // (int[] pixels, int offset, int stride, int x, int y, int width, int
        // height)
        tile.setPixels(colors, 0, dim, 0, 0, dim, dim);
        return tile;
    }

    /**
     * helper function - convert a bitmap into a tile
     * 
     * @param bitmap
     *            bitmap to convert into a tile
     * @return the tile
     */
    private static Tile convertBitmap(final Bitmap bitmap) {
        // Convert it into byte array (required for tile creation)
        final ByteArrayOutputStream stream = new ByteArrayOutputStream();
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream);
        final byte[] bitmapdata = stream.toByteArray();
        return new Tile(HeatmapTileProvider.TILE_DIM,
                HeatmapTileProvider.TILE_DIM, bitmapdata);
    }

    /**
     * Applies a 2D Gaussian convolution to the input grid, returning a 2D grid
     * cropped of padding.
     * 
     * @param grid
     *            Raw input grid to convolve: dimension (dim + 2 * radius) x
     *            (dim + 2 * radius) ie dim * dim with padding of size radius
     * @param kernel
     *            Pre-computed Gaussian kernel of size radius * 2 + 1
     * @return the smoothened grid
     */
    static double[][] convolve(final double[][] grid, final double[] kernel) {
        // Calculate radius size
        final int radius = (int) Math.floor(kernel.length / 2.0);
        // Padded dimension
        final int dimOld = grid.length;
        // Calculate final (non padded) dimension
        final int dim = dimOld - (2 * radius);

        // Upper and lower limits of non padded (inclusive)
        final int lowerLimit = radius;
        final int upperLimit = (radius + dim) - 1;

        // Convolve horizontally
        final double[][] intermediate = new double[dimOld][dimOld];

        // Need to convolve every point (including those outside of non-padded
        // area)
        // but only need to add to points within non-padded area
        int x, y, x2, xUpperLimit, initial;
        double val;
        for (x = 0; x < dimOld; x++) {
            for (y = 0; y < dimOld; y++) {
                // for each point (x, y)
                val = grid[x][y];
                // only bother if something there
                if (val != 0) {
                    // need to "apply" convolution from that point to every
                    // point in
                    // (max(lowerLimit, x - radius), y) to (min(upperLimit, x +
                    // radius), y)
                    xUpperLimit = ((upperLimit < (x + radius)) ? upperLimit : x
                            + radius) + 1;
                    // Replace Math.max
                    initial = (lowerLimit > (x - radius)) ? lowerLimit : x
                            - radius;
                    for (x2 = initial; x2 < xUpperLimit; x2++) {
                        // multiplier for x2 = x - radius is kernel[0]
                        // x2 = x + radius is kernel[radius * 2]
                        // so multiplier for x2 in general is kernel[x2 - (x -
                        // radius)]
                        intermediate[x2][y] += val * kernel[x2 - (x - radius)];
                    }
                }
            }
        }

        // Convolve vertically
        final double[][] outputGrid = new double[dim][dim];

        // Similarly, need to convolve every point, but only add to points
        // within non-padded area
        // However, we are adding to a smaller grid here (previously, was to a
        // grid of same size)
        int y2, yUpperLimit;

        // Don't care about convolving parts in horizontal padding - wont impact
        // inner
        for (x = lowerLimit; x < (upperLimit + 1); x++) {
            for (y = 0; y < dimOld; y++) {
                // for each point (x, y)
                val = intermediate[x][y];
                // only bother if something there
                if (val != 0) {
                    // need to "apply" convolution from that point to every
                    // point in
                    // (x, max(lowerLimit, y - radius) to (x, min(upperLimit, y
                    // + radius))
                    // Don't care about
                    yUpperLimit = ((upperLimit < (y + radius)) ? upperLimit : y
                            + radius) + 1;
                    // replace math.max
                    initial = (lowerLimit > (y - radius)) ? lowerLimit : y
                            - radius;
                    for (y2 = initial; y2 < yUpperLimit; y2++) {
                        // Similar logic to above
                        // subtract, as adding to a smaller grid
                        outputGrid[x - radius][y2 - radius] += val
                                * kernel[y2 - (y - radius)];
                    }
                }
            }
        }

        return outputGrid;
    }

    /**
     * Generates 1D Gaussian kernel density function, as a double array of size
     * radius * 2 + 1 Normalised with central value of 1.
     * 
     * @param radius
     *            radius of the kernel
     * @param sd
     *            standard deviation of the Gaussian function
     * @return generated Gaussian kernel
     */
    static double[] generateKernel(final int radius, final double sd) {
        final double[] kernel = new double[(radius * 2) + 1];
        for (int i = -radius; i <= radius; i++) {
            kernel[i + radius] = (Math.exp((-i * i) / (2 * sd * sd)));
        }
        return kernel;
    }

    /**
     * Helper function for quadtree creation
     * 
     * @param points
     *            Collection of WeightedLatLng to calculate bounds for
     * @return Bounds that enclose the listed WeightedLatLng points
     */
    static Bounds getBounds(final Collection<WeightedLatLng> points) {

        // Use an iterator, need to access any one point of the collection for
        // starting bounds
        final Iterator<WeightedLatLng> iter = points.iterator();

        final WeightedLatLng first = iter.next();

        double minX = first.getPoint().x;
        double maxX = first.getPoint().x;
        double minY = first.getPoint().y;
        double maxY = first.getPoint().y;

        while (iter.hasNext()) {
            final WeightedLatLng l = iter.next();
            final double x = l.getPoint().x;
            final double y = l.getPoint().y;
            // Extend bounds if necessary
            if (x < minX) {
                minX = x;
            }
            if (x > maxX) {
                maxX = x;
            }
            if (y < minY) {
                minY = y;
            }
            if (y > maxY) {
                maxY = y;
            }
        }

        return new Bounds(minX, maxX, minY, maxY);
    }

    /**
     * Calculate a reasonable maximum intensity value to map to maximum color
     * intensity
     * 
     * @param points
     *            Collection of LatLngs to put into buckets
     * @param bounds
     *            Bucket boundaries
     * @param radius
     *            radius of convolution
     * @param screenDim
     *            larger dimension of screen in pixels (for scale)
     * @return Approximate max value
     */
    static double getMaxValue(final Collection<WeightedLatLng> points,
            final Bounds bounds, final int radius, final int screenDim) {
        // Approximate scale as if entire heatmap is on the screen
        // ie scale dimensions to larger of width or height (screenDim)
        final double minX = bounds.minX;
        final double maxX = bounds.maxX;
        final double minY = bounds.minY;
        final double maxY = bounds.maxY;
        final double boundsDim = ((maxX - minX) > (maxY - minY)) ? maxX - minX
                : maxY - minY;

        // Number of buckets: have diameter sized buckets
        final int nBuckets = (int) ((screenDim / (2 * radius)) + 0.5);
        // Scaling factor to convert width in terms of point distance, to which
        // bucket
        final double scale = nBuckets / boundsDim;

        // Make buckets
        // Use a sparse array - use LongSparseArray just in case
        final LongSparseArray<LongSparseArray<Double>> buckets = new LongSparseArray<LongSparseArray<Double>>();
        // double[][] buckets = new double[nBuckets][nBuckets];

        // Assign into buckets + find max value as we go along
        double x, y;
        double max = 0;
        for (final WeightedLatLng l : points) {
            x = l.getPoint().x;
            y = l.getPoint().y;

            final int xBucket = (int) ((x - minX) * scale);
            final int yBucket = (int) ((y - minY) * scale);

            // Check if x bucket exists, if not make it
            LongSparseArray<Double> column = buckets.get(xBucket);
            if (column == null) {
                column = new LongSparseArray<Double>();
                buckets.put(xBucket, column);
            }
            // Check if there is already a y value there
            Double value = column.get(yBucket);
            if (value == null) {
                value = 0.0;
            }
            value += l.getIntensity();
            // Yes, do need to update it, despite it being a Double.
            column.put(yBucket, value);

            if (value > max) {
                max = value;
            }
        }

        return max;
    }

    /**
     * Helper function - wraps LatLngs into WeightedLatLngs.
     * 
     * @param data
     *            Data to wrap (LatLng)
     * @return Data, in WeightedLatLng form
     */
    private static Collection<WeightedLatLng> wrapData(
            final Collection<LatLng> data) {
        // Use an ArrayList as it is a nice collection
        final ArrayList<WeightedLatLng> weightedData = new ArrayList<WeightedLatLng>();

        for (final LatLng l : data) {
            weightedData.add(new WeightedLatLng(l));
        }

        return weightedData;
    }

    /**
     * Bounds of the quad tree
     */
    private Bounds mBounds;

    /**
     * Color map to use to color tiles
     */
    private int[] mColorMap;

    /**
     * Collection of all the data.
     */
    private Collection<WeightedLatLng> mData;

    /**
     * Gradient of the color map
     */
    private Gradient mGradient;

    /**
     * Kernel to use for convolution
     */
    private double[] mKernel;

    /**
     * Maximum intensity estimates for heatmap
     */
    private double[] mMaxIntensity;

    /**
     * Opacity of the overall heatmap overlay [0...1]
     */
    private double mOpacity;

    /**
     * Heatmap point radius.
     */
    private int mRadius;

    /**
     * Quad tree of all the points to display in the heatmap
     */
    private PointQuadTree<WeightedLatLng> mTree;

    private HeatmapTileProvider(final Builder builder) {
        // Get parameters from builder
        mData = builder.data;

        mRadius = builder.radius;
        mGradient = builder.gradient;
        mOpacity = builder.opacity;

        // Compute kernel density function (sd = 1/3rd of radius)
        mKernel = HeatmapTileProvider.generateKernel(mRadius, mRadius / 3.0);

        // Generate color map
        setGradient(mGradient);

        // Set the data
        setWeightedData(mData);
    }

    /**
     * Gets array of maximum intensity values to use with the heatmap for each
     * zoom level This is the value that the highest color on the color map
     * corresponds to
     * 
     * @param radius
     *            radius of the heatmap
     * @return array of maximum intensities
     */
    private double[] getMaxIntensities(final int radius) {
        // Can go from zoom level 3 to zoom level 22
        final double[] maxIntensityArray = new double[HeatmapTileProvider.MAX_ZOOM_LEVEL];

        // Calculate max intensity for each zoom level
        for (int i = HeatmapTileProvider.DEFAULT_MIN_ZOOM; i < HeatmapTileProvider.DEFAULT_MAX_ZOOM; i++) {
            // Each zoom level multiplies viewable size by 2
            maxIntensityArray[i] = HeatmapTileProvider
                    .getMaxValue(mData, mBounds, radius,
                            (int) (HeatmapTileProvider.SCREEN_SIZE * Math.pow(
                                    2, i - 3)));
            if (i == HeatmapTileProvider.DEFAULT_MIN_ZOOM) {
                for (int j = 0; j < i; j++) {
                    maxIntensityArray[j] = maxIntensityArray[i];
                }
            }
        }
        for (int i = HeatmapTileProvider.DEFAULT_MAX_ZOOM; i < HeatmapTileProvider.MAX_ZOOM_LEVEL; i++) {
            maxIntensityArray[i] = maxIntensityArray[HeatmapTileProvider.DEFAULT_MAX_ZOOM - 1];
        }

        return maxIntensityArray;
    }

    /**
     * Creates tile.
     * 
     * @param x
     *            X coordinate of tile.
     * @param y
     *            Y coordinate of tile.
     * @param zoom
     *            Zoom level.
     * @return image in Tile format
     */
    @Override
    public Tile getTile(final int x, final int y, final int zoom) {
        // Convert tile coordinates and zoom into Point/Bounds format
        // Know that at zoom level 0, there is one tile: (0, 0) (arbitrary width
        // 512)
        // Each zoom level multiplies number of tiles by 2
        // Width of the world = WORLD_WIDTH = 1
        // x = [0, 1) corresponds to [-180, 180)

        // calculate width of one tile, given there are 2 ^ zoom tiles in that
        // zoom level
        // In terms of world width units
        final double tileWidth = HeatmapTileProvider.WORLD_WIDTH
                / Math.pow(2, zoom);

        // how much padding to include in search
        // is to tileWidth as mRadius (padding in terms of pixels) is to
        // TILE_DIM
        // In terms of world width units
        final double padding = (tileWidth * mRadius)
                / HeatmapTileProvider.TILE_DIM;

        // padded tile width
        // In terms of world width units
        final double tileWidthPadded = tileWidth + (2 * padding);

        // padded bucket width - divided by number of buckets
        // In terms of world width units
        final double bucketWidth = tileWidthPadded
                / (HeatmapTileProvider.TILE_DIM + (mRadius * 2));

        // Make bounds: minX, maxX, minY, maxY
        final double minX = (x * tileWidth) - padding;
        final double maxX = ((x + 1) * tileWidth) + padding;
        final double minY = (y * tileWidth) - padding;
        final double maxY = ((y + 1) * tileWidth) + padding;

        // Deal with overlap across lat = 180
        // Need to make it wrap around both ways
        // However, maximum tile size is such that you wont ever have to deal
        // with both, so
        // hence, the else
        // Note: Tile must remain square, so cant optimise by editing bounds
        double xOffset = 0;
        Collection<WeightedLatLng> wrappedPoints = new ArrayList<WeightedLatLng>();
        if (minX < 0) {
            // Need to consider "negative" points
            // (minX to 0) -> (512+minX to 512) ie +512
            // add 512 to search bounds and subtract 512 from actual points
            final Bounds overlapBounds = new Bounds(minX
                    + HeatmapTileProvider.WORLD_WIDTH,
                    HeatmapTileProvider.WORLD_WIDTH, minY, maxY);
            xOffset = -HeatmapTileProvider.WORLD_WIDTH;
            wrappedPoints = mTree.search(overlapBounds);
        } else if (maxX > HeatmapTileProvider.WORLD_WIDTH) {
            // Cant both be true as then tile covers whole world
            // Need to consider "overflow" points
            // (512 to maxX) -> (0 to maxX-512) ie -512
            // subtract 512 from search bounds and add 512 to actual points
            final Bounds overlapBounds = new Bounds(0, maxX
                    - HeatmapTileProvider.WORLD_WIDTH, minY, maxY);
            xOffset = HeatmapTileProvider.WORLD_WIDTH;
            wrappedPoints = mTree.search(overlapBounds);
        }

        // Main tile bounds to search
        final Bounds tileBounds = new Bounds(minX, maxX, minY, maxY);

        // If outside of *padded* quadtree bounds, return blank tile
        // This is comparing our bounds to the padded bounds of all points in
        // the quadtree
        // ie tiles that don't touch the heatmap at all
        final Bounds paddedBounds = new Bounds(mBounds.minX - padding,
                mBounds.maxX + padding, mBounds.minY - padding, mBounds.maxY
                        + padding);
        if (!tileBounds.intersects(paddedBounds)) {
            return TileProvider.NO_TILE;
        }

        // Search for all points within tile bounds
        final Collection<WeightedLatLng> points = mTree.search(tileBounds);

        // If no points, return blank tile
        if (points.isEmpty()) {
            return TileProvider.NO_TILE;
        }

        // Quantize points
        final double[][] intensity = new double[HeatmapTileProvider.TILE_DIM
                + (mRadius * 2)][HeatmapTileProvider.TILE_DIM + (mRadius * 2)];
        for (final WeightedLatLng w : points) {
            final Point p = w.getPoint();
            final int bucketX = (int) ((p.x - minX) / bucketWidth);
            final int bucketY = (int) ((p.y - minY) / bucketWidth);
            intensity[bucketX][bucketY] += w.getIntensity();
        }
        // Quantize wraparound points (taking xOffset into account)
        for (final WeightedLatLng w : wrappedPoints) {
            final Point p = w.getPoint();
            final int bucketX = (int) (((p.x + xOffset) - minX) / bucketWidth);
            final int bucketY = (int) ((p.y - minY) / bucketWidth);
            intensity[bucketX][bucketY] += w.getIntensity();
        }

        // Convolve it ("smoothen" it out)
        final double[][] convolved = HeatmapTileProvider.convolve(intensity,
                mKernel);

        // Color it into a bitmap
        final Bitmap bitmap = HeatmapTileProvider.colorize(convolved,
                mColorMap, mMaxIntensity[zoom]);

        // Convert bitmap to tile and return
        return HeatmapTileProvider.convertBitmap(bitmap);
    }

    /* Utility functions below */

    /**
     * Changes the dataset the heatmap is portraying. Unweighted. User should
     * clear overlay's tile cache (using clearTileCache()) after calling this.
     * 
     * @param data
     *            Data set of points to use in the heatmap, as LatLngs.
     */
    public void setData(final Collection<LatLng> data) {
        // Turn them into WeightedLatLngs and delegate.
        setWeightedData(HeatmapTileProvider.wrapData(data));
    }

    /**
     * Setter for gradient/color map. User should clear overlay's tile cache
     * (using clearTileCache()) after calling this.
     * 
     * @param gradient
     *            Gradient to set
     */
    public void setGradient(final Gradient gradient) {
        mGradient = gradient;
        mColorMap = gradient.generateColorMap(mOpacity);
    }

    /**
     * Setter for opacity User should clear overlay's tile cache (using
     * clearTileCache()) after calling this.
     * 
     * @param opacity
     *            opacity to set
     */
    public void setOpacity(final double opacity) {
        mOpacity = opacity;
        // need to recompute kernel color map
        setGradient(mGradient);
    }

    /**
     * Setter for radius. User should clear overlay's tile cache (using
     * clearTileCache()) after calling this.
     * 
     * @param radius
     *            Radius to set
     */
    public void setRadius(final int radius) {
        mRadius = radius;
        // need to recompute kernel
        mKernel = HeatmapTileProvider.generateKernel(mRadius, mRadius / 3.0);
        // need to recalculate max intensity
        mMaxIntensity = getMaxIntensities(mRadius);
    }

    /**
     * Changes the dataset the heatmap is portraying. Weighted. User should
     * clear overlay's tile cache (using clearTileCache()) after calling this.
     * 
     * @param data
     *            Data set of points to use in the heatmap, as LatLngs. Note:
     *            Editing data without calling setWeightedData again will not
     *            update the data displayed on the map, but will impact
     *            calculation of max intensity values, as the collection you
     *            pass in is stored. Outside of changing the data, max intensity
     *            values are calculated only upon changing the radius.
     */
    public void setWeightedData(final Collection<WeightedLatLng> data) {
        // Change point set
        mData = data;

        // Check point set is OK
        if (mData.isEmpty()) {
            throw new IllegalArgumentException("No input points.");
        }

        // Because quadtree bounds are final once the quadtree is created, we
        // cannot add
        // points outside of those bounds to the quadtree after creation.
        // As quadtree creation is actually quite lightweight/fast as compared
        // to other functions
        // called in heatmap creation, re-creating the quadtree is an acceptable
        // solution here.

        // Make the quad tree
        mBounds = HeatmapTileProvider.getBounds(mData);

        mTree = new PointQuadTree<WeightedLatLng>(mBounds);

        // Add points to quad tree
        for (final WeightedLatLng l : mData) {
            mTree.add(l);
        }

        // Calculate reasonable maximum intensity for color scale (user can also
        // specify)
        // Get max intensities
        mMaxIntensity = getMaxIntensities(mRadius);
    }
}
